{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {
    "nbpresent": {
     "id": "42b5e80b-ad1d-4335-a1f7-10a91127e3dc"
    }
   },
   "source": [
    "# Amazon SageMaker Batch Transform: Associate prediction results with their corresponding input records\n",
    "_**Use SageMaker's XGBoost to train a binary classification model and for a list of tumors in batch file, predict if each is malignant**_\n",
    "\n",
    "_**It also shows how to use the input output joining / filter feature in Batch transform in details**_\n",
    "\n",
    "---\n",
    "\n",
    "\n",
    "\n",
    "## Background\n",
    "This purpose of this notebook is to train a model using SageMaker's XGBoost and UCI's breast cancer diagnostic data set to illustrate at how to run batch inferences and how to use the Batch Transform I/O join feature. UCI's breast cancer diagnostic data set is available at https://archive.ics.uci.edu/ml/datasets/Breast+Cancer+Wisconsin+%28Diagnostic%29. The data set is also available on Kaggle at https://www.kaggle.com/uciml/breast-cancer-wisconsin-data. The purpose here is to use this data set to build a predictve model of whether a breast mass image indicates benign or malignant tumor. \n",
    "\n",
    "\n",
    "\n",
    "---\n",
    "\n",
    "## Setup\n",
    "\n",
    "Let's start by specifying:\n",
    "\n",
    "* The SageMaker role arn used to give training and batch transform access to your data. The snippet below will use the same role used by your SageMaker notebook instance. Otherwise, specify the full ARN of a role with the SageMakerFullAccess policy attached.\n",
    "* The S3 bucket that you want to use for training and storing model objects."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "isConfigCell": true,
    "nbpresent": {
     "id": "6427e831-8f89-45c0-b150-0b134397d79a"
    }
   },
   "outputs": [],
   "source": [
    "import os\n",
    "import boto3\n",
    "import sagemaker\n",
    "\n",
    "role = sagemaker.get_execution_role()\n",
    "sess = sagemaker.Session()\n",
    "\n",
    "bucket=sess.default_bucket()\n",
    "prefix = 'sagemaker/breast-cancer-prediction-xgboost' # place to upload training files within the bucket"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "nbpresent": {
     "id": "142777ae-c072-448e-b941-72bc75735d01"
    }
   },
   "source": [
    "---\n",
    "## Data preparation\n",
    "\n",
    "Data Source: https://archive.ics.uci.edu/ml/machine-learning-databases/breast-cancer-wisconsin/wdbc.data\n",
    "        https://www.kaggle.com/uciml/breast-cancer-wisconsin-data\n",
    "\n",
    "Let's download the data and save it in the local folder with the name data.csv and take a look at it."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "nbpresent": {
     "id": "f8976dad-6897-4c7e-8c95-ae2f53070ef5"
    }
   },
   "outputs": [],
   "source": [
    "import pandas as pd\n",
    "import numpy as np\n",
    "\n",
    "data = pd.read_csv('https://archive.ics.uci.edu/ml/machine-learning-databases/breast-cancer-wisconsin/wdbc.data', header = None)\n",
    "\n",
    "# specify columns extracted from wbdc.names\n",
    "data.columns = [\"id\",\"diagnosis\",\"radius_mean\",\"texture_mean\",\"perimeter_mean\",\"area_mean\",\"smoothness_mean\",\n",
    "                \"compactness_mean\",\"concavity_mean\",\"concave points_mean\",\"symmetry_mean\",\"fractal_dimension_mean\",\n",
    "                \"radius_se\",\"texture_se\",\"perimeter_se\",\"area_se\",\"smoothness_se\",\"compactness_se\",\"concavity_se\",\n",
    "                \"concave points_se\",\"symmetry_se\",\"fractal_dimension_se\",\"radius_worst\",\"texture_worst\",\n",
    "                \"perimeter_worst\",\"area_worst\",\"smoothness_worst\",\"compactness_worst\",\"concavity_worst\",\n",
    "                \"concave points_worst\",\"symmetry_worst\",\"fractal_dimension_worst\"] \n",
    "\n",
    "# save the data\n",
    "data.to_csv(\"data.csv\", sep=',', index=False)\n",
    "\n",
    "data.sample(8)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "#### Key observations:\n",
    "* The data has 569 observations and 32 columns.\n",
    "* The first field is the 'id' attribute that we will want to drop before batch inference and add to the final inference output next to the probability of malignancy.\n",
    "* Second field, 'diagnosis', is an indicator of the actual diagnosis ('M' = Malignant; 'B' = Benign).\n",
    "* There are 30 other numeric features that we will use for training and inferencing."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Let's replace the M/B diagnosis with a 1/0 boolean value. "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "data['diagnosis']=data['diagnosis'].apply(lambda x: ((x ==\"M\"))+0)\n",
    "data.sample(8)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Let's split the data as follows: 80% for training, 10% for validation and let's set 10% aside for our batch inference job. In addition, let's drop the 'id' field on the training set and validation set as 'id' is not a training feature. For our batch set however, we keep the 'id' feature. We'll want to filter it out prior to running our inferences so that the input data features match the ones of training set and then ultimately, we'll want to join it with inference result. We are however dropping the diagnosis attribute for the batch set since this is what we'll try to predict."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "#data split in three sets, training, validation and batch inference\n",
    "rand_split = np.random.rand(len(data))\n",
    "train_list = rand_split < 0.8\n",
    "val_list = (rand_split >= 0.8) & (rand_split < 0.9)\n",
    "batch_list = rand_split >= 0.9\n",
    "\n",
    "data_train = data[train_list].drop(['id'],axis=1)\n",
    "data_val = data[val_list].drop(['id'],axis=1)\n",
    "data_batch = data[batch_list].drop(['diagnosis'],axis=1)\n",
    "data_batch_noID = data_batch.drop(['id'],axis=1)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "nbpresent": {
     "id": "ff9d10f9-b611-423b-80da-6dcdafd1c8b9"
    }
   },
   "source": [
    "Let's upload those data sets in S3"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "nbpresent": {
     "id": "cd8e3431-79d9-40b6-91d1-d67cd61894e7"
    }
   },
   "outputs": [],
   "source": [
    "train_file = 'train_data.csv'\n",
    "data_train.to_csv(train_file,index=False,header=False)\n",
    "sess.upload_data(train_file, key_prefix='{}/train'.format(prefix))\n",
    "\n",
    "validation_file = 'validation_data.csv'\n",
    "data_val.to_csv(validation_file,index=False,header=False)\n",
    "sess.upload_data(validation_file, key_prefix='{}/validation'.format(prefix))\n",
    "\n",
    "batch_file = 'batch_data.csv'\n",
    "data_batch.to_csv(batch_file,index=False,header=False)\n",
    "sess.upload_data(batch_file, key_prefix='{}/batch'.format(prefix))\n",
    "    \n",
    "batch_file_noID = 'batch_data_noID.csv'\n",
    "data_batch_noID.to_csv(batch_file_noID,index=False,header=False)\n",
    "sess.upload_data(batch_file_noID, key_prefix='{}/batch'.format(prefix))   \n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "nbpresent": {
     "id": "71cbcebd-a2a5-419e-8e50-b2bc0909f564"
    }
   },
   "source": [
    "---\n",
    "\n",
    "## Training job and model creation"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "collapsed": true,
    "nbpresent": {
     "id": "bd113b8e-adc1-4091-a26f-a426149fe604"
    }
   },
   "source": [
    "The below cell uses the [SageMaker Python SDK](https://github.com/aws/sagemaker-python-sdk) to kick off the training job using both our training set and validation set. Not that the objective is set to 'binary:logistic' which trains a model to output a probability between 0 and 1 (here the probability of a tumor being malignant)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "nbpresent": {
     "id": "f3b125ad-a2d5-464c-8cfa-bd203034eee4"
    }
   },
   "outputs": [],
   "source": [
    "%%time\n",
    "from time import gmtime, strftime\n",
    "from sagemaker.amazon.amazon_estimator import get_image_uri\n",
    "\n",
    "\n",
    "job_name = 'xgb-' + strftime(\"%Y-%m-%d-%H-%M-%S\", gmtime())\n",
    "output_location = 's3://{}/{}/output/{}'.format(bucket, prefix, job_name)\n",
    "image = get_image_uri(boto3.Session().region_name, 'xgboost')\n",
    "\n",
    "sm_estimator = sagemaker.estimator.Estimator(image,\n",
    "                                             role,\n",
    "                                             train_instance_count=1,\n",
    "                                             train_instance_type='ml.m5.4xlarge',\n",
    "                                             train_volume_size=50,\n",
    "                                             input_mode='File',\n",
    "                                             output_path=output_location,\n",
    "                                             sagemaker_session=sess)\n",
    "\n",
    "sm_estimator.set_hyperparameters(objective=\"binary:logistic\",\n",
    "                                 max_depth=5,\n",
    "                                 eta=0.2,\n",
    "                                 gamma=4,\n",
    "                                 min_child_weight=6,\n",
    "                                 subsample=0.8,\n",
    "                                 silent=0,\n",
    "                                 num_round=100)\n",
    "\n",
    "train_data = sagemaker.session.s3_input('s3://{}/{}/train'.format(bucket, prefix), distribution='FullyReplicated', \n",
    "                                        content_type='text/csv', s3_data_type='S3Prefix')\n",
    "validation_data = sagemaker.session.s3_input('s3://{}/{}/validation'.format(bucket, prefix), distribution='FullyReplicated', \n",
    "                                             content_type='text/csv', s3_data_type='S3Prefix')\n",
    "data_channels = {'train': train_data, 'validation': validation_data}\n",
    "\n",
    "\n",
    "# Start training by calling the fit method in the estimator\n",
    "sm_estimator.fit(inputs=data_channels, logs=True)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "collapsed": true,
    "nbpresent": {
     "id": "397fb60a-c48b-453f-88ea-4d832b70c919"
    }
   },
   "source": [
    "---\n",
    "\n",
    "## Batch Transform\n",
    "\n",
    "In SageMaker Batch Transform, we introduced 3 new attributes - __input_filter__, __join_source__ and __output_filter__. In the below cell, we use the [SageMaker Python SDK](https://github.com/aws/sagemaker-python-sdk) to kick-off several Batch Transform jobs using different configurations of these 3 new attributes. Please refer to [this page](https://docs.aws.amazon.com/sagemaker/latest/dg/batch-transform-data-processing.html) to learn more about how to use them.\n",
    "\n",
    "\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "#### 1. Create a transform job with the default configurations\n",
    "Let's first skip these 3 new attributes and inspect the inference results. We'll use it as a baseline to compare to the results with data processing."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "%%time\n",
    "\n",
    "sm_transformer = sm_estimator.transformer(1, 'ml.m4.xlarge')\n",
    "\n",
    "# start a transform job\n",
    "input_location = 's3://{}/{}/batch/{}'.format(bucket, prefix, batch_file_noID) # use input data without ID column\n",
    "sm_transformer.transform(input_location, split_type='Line')\n",
    "sm_transformer.wait()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Let's inspect the output of the Batch Transform job in S3. It should show the list probabilities of tumors being malignant."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import json\n",
    "import io\n",
    "from urllib.parse import urlparse\n",
    "\n",
    "def get_csv_output_from_s3(s3uri, file_name):\n",
    "    parsed_url = urlparse(s3uri)\n",
    "    bucket_name = parsed_url.netloc\n",
    "    prefix = parsed_url.path[1:]\n",
    "    s3 = boto3.resource('s3')\n",
    "    obj = s3.Object(bucket_name, '{}/{}'.format(prefix, file_name))\n",
    "    return obj.get()[\"Body\"].read().decode('utf-8')    \n",
    "\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "output = get_csv_output_from_s3(sm_transformer.output_path, '{}.out'.format(batch_file_noID))\n",
    "output_df = pd.read_csv(io.StringIO(output), sep=\",\", header=None)\n",
    "output_df.head(8)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "#### 2. Join the input and the prediction results \n",
    "Now, let's associate the prediction results with their corresponding input records. We can also use the __input_filter__ to exclude the ID column easily and there's no need to have a separate file in S3.\n",
    "\n",
    "* Set __input_filter__ to \"$[1:]\": indicates that we are excluding column 0 (the 'ID') before processing the inferences and keeping everything from column 1 to the last column (all the features or predictors)  \n",
    "  \n",
    "  \n",
    "* Set __join_source__ to \"Input\": indicates our desire to join the input data with the inference results  \n",
    "\n",
    "* Leave __output_filter__ to default ('$'), indicating that the joined input and inference results be will saved as output."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# content_type / accept and split_type / assemble_with are required to use IO joining feature\n",
    "sm_transformer.assemble_with = 'Line'\n",
    "sm_transformer.accept = 'text/csv'\n",
    "\n",
    "# start a transform job\n",
    "input_location = 's3://{}/{}/batch/{}'.format(bucket, prefix, batch_file) # use input data with ID column cause InputFilter will filter it out\n",
    "sm_transformer.transform(input_location, split_type='Line', content_type='text/csv', input_filter='$[1:]', join_source='Input')\n",
    "sm_transformer.wait()\n",
    "\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Let's inspect the output of the Batch Transform job in S3. It should show the list of tumors identified by their original feature columns and their corresponding probabilities of being malignant."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "output = get_csv_output_from_s3(sm_transformer.output_path, '{}.out'.format(batch_file))\n",
    "output_df = pd.read_csv(io.StringIO(output), sep=\",\", header=None)\n",
    "output_df.head(8)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "#### 3. Update the output filter to keep only ID and prediction results\n",
    "Let's change __output_filter__ to \"$[0,-1]\", indicating that when presenting the output, we only want to keep column 0 (the 'ID') and the last column (the inference result i.e. the probability of a given tumor to be malignant)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# start another transform job\n",
    "sm_transformer.transform(input_location, split_type='Line', content_type='text/csv', input_filter='$[1:]', join_source='Input', output_filter='$[0,-1]')\n",
    "sm_transformer.wait()\n",
    "\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Now, let's inspect the output of the Batch Transform job in S3 again. It should show 2 columns: the ID and their corresponding probabilities of being malignant."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "output = get_csv_output_from_s3(sm_transformer.output_path, '{}.out'.format(batch_file))\n",
    "output_df = pd.read_csv(io.StringIO(output), sep=\",\", header=None)\n",
    "output_df.head(8)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "In summary, we can use newly introduced 3 attributes - __input_filter__, __join_source__, __output_filter__ to \n",
    "1. Filter / select useful features from the input dataset. e.g. exclude ID columns.\n",
    "2. Associate the prediction results with their corresponding input records.\n",
    "3. Filter the original or joined results before saving to S3. e.g. keep ID and probability columns only."
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "conda_python3",
   "language": "python",
   "name": "conda_python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.6.5"
  },
  "notice": "Copyright 2017 Amazon.com, Inc. or its affiliates. All Rights Reserved.  Licensed under the Apache License, Version 2.0 (the License). You may not use this file except in compliance with the License. A copy of the License is located at http://aws.amazon.com/apache2.0/ or in the license file accompanying this file. This file is distributed on an AS IS BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License."
 },
 "nbformat": 4,
 "nbformat_minor": 2
}
